32장. 테스트 작성하기
코드를 짜고 나면 “잘 돌아갈까?” 라는 질문이 따라온다. 한두 번은 손으로 돌려 봐도 되지만, 규모가 커지면 그 방법으로는 감당이 안 된다.
이때 등장하는 것이 자동화된 테스트다.
Go 는 외부 도구 없이도
표준 testing 패키지만으로 충분히 테스트할 수 있다.
목표:
- 왜 테스트를 쓰는지 납득하기
testing패키지의 기본 사용법 익히기- 테이블 기반 테스트와 서브테스트 다루기
- 벤치마크, 예제, 커버리지까지 한 바퀴 돌아 보기
32.1 왜 테스트를 작성하는가
테스트는 단순한 “검증 도구” 이상의 가치를 가진다.
회귀 방지
새 기능을 넣다가 기존 기능을 망가뜨리는 사고를 흔히 본다. 테스트가 있다면 망가지는 순간 빨간불이 켜진다.
리팩터링 안전망
코드를 깔끔히 정리하고 싶을 때 “건드리면 뭐가 깨질지 모른다” 는 두려움이 가장 큰 장애물이다.
테스트가 든든히 받쳐 주면 구조를 바꿔도 의도된 결과만 같으면 안심할 수 있다.
사양의 역할
테스트 코드는 곧 “이 함수가 어떻게 동작해야 하는가” 의 명세다. 새로 합류한 동료가 읽기에도, 미래의 내가 다시 보기에도 좋다.
잘 짜인 테스트는 주석보다 정확하다. 주석은 코드와 함께 변하지 않을 수 있지만, 테스트는 깨지는 순간 알려 준다.
32.2 testing 패키지 한눈에 보기
Go 의 테스트는 표준 라이브러리 testing 으로 한다.
별도 프레임워크를 깔지 않는다.
파일 이름 관례
테스트 코드는 _test.go 로 끝나는 파일에 둔다.
| 파일 | 역할 |
|---|---|
math.go | 실제 코드 |
math_test.go | 테스트 코드 |
빌드할 때는 _test.go 파일이 자동으로 제외된다.
배포 바이너리에는 테스트 코드가 섞이지 않는다.
함수 시그니처
func TestXxx(t *testing.T) { ... }
- 이름은 반드시
Test로 시작 - 그 뒤 첫 글자는 대문자
- 매개변수는
*testing.T하나
같은 패키지냐, 분리 패키지냐
테스트 파일은 보통 두 가지 방식으로 둘 수 있다.
- 같은 패키지 —
package math그대로- 패키지 내부 식별자(소문자 함수 등)에 접근 가능
_test분리 패키지 —package math_test- 외부 사용자 입장에서 쓰는 것처럼 테스트
- 공개 API 만 보임
대부분의 경우는 같은 패키지 방식으로 충분하다.
32.3 첫 테스트 작성
간단한 함수를 하나 만들고 테스트해 본다.
대상 함수
mathx/mathx.go:
package mathx
// Add 는 두 정수의 합을 반환한다.
func Add(a, b int) int {
return a + b
}
테스트 코드
mathx/mathx_test.go:
package mathx
import "testing"
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
t.Errorf("Add(2, 3) = %d, want %d", got, want)
}
}
실행
같은 디렉터리에서 다음을 실행한다.
go test
통과하면 이렇게 나온다.
ok example.com/mathx 0.123s
자세히 보고 싶다면 -v 옵션을 붙인다.
go test -v
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok example.com/mathx 0.123s
실패 시 모습
일부러 want := 6 으로 바꿔서 실행해 보자.
=== RUN TestAdd
mathx_test.go:9: Add(2, 3) = 5, want 6
--- FAIL: TestAdd (0.00s)
FAIL
어느 줄에서 어떤 차이가 났는지 깔끔하게 보여 준다.
32.4 t.Error, t.Errorf, t.Fatal
테스트 실패를 보고하는 메서드는 여러 가지다. 가장 자주 쓰는 두 그룹을 비교한다.
Error 계열 (실패 후 계속 진행)
| 메서드 | 동작 |
|---|---|
t.Error(args...) | 실패 표시, 메시지 출력, 이후 코드 계속 실행 |
t.Errorf(format, ...) | 위와 같되 포맷 문자열 사용 |
Fatal 계열 (실패 후 즉시 중단)
| 메서드 | 동작 |
|---|---|
t.Fatal(args...) | 실패 표시 후 해당 테스트 즉시 종료 |
t.Fatalf(format, ...) | 위와 같되 포맷 문자열 사용 |
언제 어느 쪽?
- 한 검사가 실패해도 다음 검사가 의미 있다면 →
Error - 한 검사가 실패하면 이후 코드가 의미 없다면 →
Fatal
예를 들어 파일을 열지 못했으면
이후 코드는 돌릴 가치가 없으니 Fatal 이 맞다.
f, err := os.Open("data.txt")
if err != nil {
t.Fatalf("파일 열기 실패: %v", err)
}
defer f.Close()
// 이후 코드는 f 가 유효하다는 전제
메시지 포맷 팁
좋은 실패 메시지는 세 가지를 담는다.
- 어떤 함수를 호출했는가
- 무엇이 나왔는가 (
got) - 무엇을 기대했는가 (
want)
t.Errorf("Add(%d, %d) = %d, want %d", a, b, got, want)
32.5 테이블 기반 테스트
비슷한 검사를 여러 입력에 대해 반복하고 싶을 때 Go 에서는 테이블 기반 테스트 가 관례다.
기본 구조
입력과 기대값 쌍을 슬라이스로 만들고
for range 로 돌린다.
func TestAdd(t *testing.T) {
cases := []struct {
name string
a, b int
want int
}{
{"양수+양수", 2, 3, 5},
{"음수 포함", -1, 1, 0},
{"0 포함", 0, 7, 7},
}
for _, c := range cases {
got := Add(c.a, c.b)
if got != c.want {
t.Errorf("%s: Add(%d,%d)=%d, want %d",
c.name, c.a, c.b, got, c.want)
}
}
}
서브테스트로 깔끔하게 — t.Run
각 케이스를 별도의 테스트처럼 다루고 싶다면
t.Run 으로 감싼다.
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := Add(c.a, c.b)
if got != c.want {
t.Errorf("Add(%d,%d)=%d, want %d",
c.a, c.b, got, c.want)
}
})
}
이렇게 하면 출력이 다음처럼 분리된다.
=== RUN TestAdd
=== RUN TestAdd/양수+양수
=== RUN TestAdd/음수_포함
=== RUN TestAdd/0_포함
--- PASS: TestAdd
--- PASS: TestAdd/양수+양수
--- PASS: TestAdd/음수_포함
--- PASS: TestAdd/0_포함
특정 서브테스트만 돌리고 싶다면 -run 으로 지정한다.
go test -run TestAdd/음수
테이블 기반의 장점
- 새 케이스 추가가 한 줄이면 끝
- 어떤 입력에서 깨졌는지 한눈에 보임
- 코드 중복이 거의 없음
거의 모든 단위 테스트는 이 패턴으로 풀린다. Go 코드를 읽다 보면 이 형태가 사방에서 보일 것이다.
32.6 테스트 헬퍼와 t.Helper
테스트 코드가 길어지면 공통 검사 로직을 헬퍼 함수로 빼고 싶어진다.
func assertEqual(t *testing.T, got, want int) {
if got != want {
t.Errorf("got %d, want %d", got, want)
}
}
이대로 쓰면 실패 메시지가 헬퍼 함수의 줄 번호를 가리킨다.
helper_test.go:5: got 3, want 5
정작 잘못된 호출은 호출자 쪽인데 말이다.
t.Helper() 한 줄로 해결
헬퍼 함수 맨 위에 t.Helper() 를 적는다.
func assertEqual(t *testing.T, got, want int) {
t.Helper()
if got != want {
t.Errorf("got %d, want %d", got, want)
}
}
이제 실패 줄 번호가 호출자 위치로 표시된다. 실패 추적이 훨씬 편해진다.
32.7 setUp / tearDown
테스트 전후에 공통 준비/정리가 필요하면 두 가지 방법이 있다.
TestMain 으로 전체 묶기
테스트 파일 안에 다음 시그니처를 두면, 같은 패키지의 모든 테스트가 시작되기 전후로 한 번씩 돈다.
func TestMain(m *testing.M) {
// 전역 준비
setupDB()
code := m.Run()
// 전역 정리
teardownDB()
os.Exit(code)
}
- 한 패키지에
TestMain은 하나만 - 호출하지 않으면
m.Run()이 자동으로 돌아간다
서브테스트 단위로 직접
각 테스트가 독립적인 자원을 쓴다면 서브테스트 안에서 직접 준비/정리하는 편이 안전하다.
func TestThing(t *testing.T) {
tmpDir := t.TempDir() // 자동 삭제
// ... 테스트 본문 ...
}
t.TempDir()가 만든 디렉터리는 테스트 종료 시 자동으로 지워진다t.Cleanup(func() { ... })로 임의 정리 작업도 등록 가능
t.Cleanup(func() {
conn.Close()
})
가능하면
TestMain보다t.Cleanup을 권한다. 테스트가 자기 정리를 책임지면 다른 테스트와의 결합이 줄어든다.
32.8 벤치마크 (26장 복습)
성능을 재고 싶다면 벤치마크 함수를 쓴다. 26장에서 잠깐 봤지만 다시 정리한다.
시그니처
func BenchmarkXxx(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}
- 이름은
Benchmark로 시작 - 매개변수는
*testing.B b.N만큼 반복 — 횟수는 Go 가 알아서 늘려 정확도 확보
실행
go test -bench=.
BenchmarkAdd-10 1000000000 0.25 ns/op
Add-10: 함수 이름과 GOMAXPROCS 값0.25 ns/op: 한 번 호출당 평균 시간
자주 같이 쓰는 옵션
| 옵션 | 의미 |
|---|---|
-bench=. | 모든 벤치마크 실행 |
-benchmem | 할당 횟수와 메모리도 출력 |
-count=N | N 회 반복해서 분산 확인 |
-benchtime=3s | 한 벤치를 3초 동안 측정 |
go test -bench=. -benchmem -count=3
32.9 예제(Example) 함수
Example 로 시작하는 함수는 두 역할을 한다.
- 테스트 (출력이 일치해야 통과)
- 문서 (godoc 페이지에 코드 예시로 노출)
func ExampleAdd() {
fmt.Println(Add(2, 3))
// Output: 5
}
- 마지막의
// Output:주석이 핵심 - 실제 출력이 이 주석과 같지 않으면 테스트 실패
순서가 중요하지 않은 출력
맵처럼 순서가 보장되지 않는 경우엔
// Unordered output: 을 쓴다.
func ExamplePrintMap() {
PrintMap(map[string]int{"a": 1, "b": 2})
// Unordered output:
// a=1
// b=2
}
패키지 / 타입 단위 예제
| 이름 | 노출 위치 |
|---|---|
Example() | 패키지 첫 페이지 |
ExampleAdd() | Add 함수 문서 |
ExampleUser_Greet() | User 타입의 Greet 메서드 문서 |
잘 짠 Example 한 개가 README 한 단락을 대신한다. 사용법을 코드로 보여 주는 것이 가장 강력하다.
32.10 커버리지
테스트가 코드의 몇 퍼센트를 거쳤는지 알고 싶다면
-cover 옵션을 쓴다.
기본 출력
go test -cover
ok example.com/mathx 0.123s coverage: 78.3% of statements
HTML 리포트
어느 줄이 안 거쳐졌는지 시각적으로 보고 싶다면 프로파일을 파일로 떨군 뒤 HTML 로 변환한다.
go test -coverprofile=c.out
go tool cover -html=c.out
브라우저가 열리며, 초록은 거친 줄, 빨강은 안 거친 줄이다.
커버리지에 대한 한 마디
100% 가 항상 좋은 건 아니다. “이 함수의 핵심 동작이 빠짐없이 테스트되는가” 가 본질이다.
- 비즈니스 로직은 높게
- 단순 게터, 단순 위임 함수는 굳이 무리하지 않기
70~80% 정도를 가이드 삼되, 숫자보다 “중요한 경로가 다 덮였나” 를 본다.
32.11 정리
이 장에서 본 것:
_test.go파일과TestXxx(t *testing.T)가 기본- 실패 보고는
Error/Fatal계열로 - 테이블 기반 테스트 +
t.Run이 거의 표준 - 헬퍼 함수에는
t.Helper()한 줄 - 벤치마크, Example, 커버리지까지 표준 도구로 다 됨
테스트는 한 번 들이는 습관이지만 시간이 갈수록 복리로 돌아온다. “테스트 없이는 손도 못 댄다” 는 동료들의 농담은 대개 진심이다.
다음 장에서는 지금까지 배운 모든 것을 모아 실전 미니 프로젝트 두 개를 만들어 본다.